package ch.bfh.ti.barcode.multiple;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import ch.bfh.ti.barcode.CodeReader;
import ch.bfh.ti.barcode.exception.DecodeException;
import ch.bfh.ti.barcode.exception.FrameMissedException;
import ch.bfh.ti.barcode.exception.NotDetectedException;

/**
 * Multiple barcodes generated with the MultipleCodeGenerator can be converted
 * back into the source data with this class. The class takes the single
 * barcodes with the { @link pushFrame()} method. The data read is being
 * buffered with the ArrayBlockingQueue. So the implementation should be thread
 * safe.
 * 
 * @author Dominik von Burg
 * 
 */
public class MultipleCodeReader {
	private final CodeReader reader;
	private int size = 0;
	private int sizeRead = 0;
	private int frameNumber = -1;
	private int totalFrames = Integer.MAX_VALUE;
	private String filename;
	private boolean disposed = false;

	private Map<Integer, Frame> orderBuffer;

	private boolean errorCorrection;
	private int errorCorrectionFrequency;

	private Object read = new Object();

	/**
	 * The MultipleCodeReader wraps a CodeReader. An instance of the CodeReader
	 * has to be provided on instantiating this class.
	 * 
	 * @param reader
	 *            The reader instance doing the conversion of the barcode
	 *            images.
	 */
	public MultipleCodeReader(CodeReader reader) {
		this.reader = reader;

		orderBuffer = new ConcurrentHashMap<Integer, Frame>();
	}

	/**
	 * Can be used to get the frame number of the last frame pushed to this
	 * instance.
	 * 
	 * @return The index of the last frame received.
	 */
	public int getFrameNumber() {
		return calculateFrameNumber(frameNumber);
	}

	private int calculateFrameNumber(int number) {
		if (errorCorrection) {
			if (number < 0)
				return -1;

			if (number == 0)
				return 0;

			return number
					- (int) Math.ceil((double) (number - 1)
							/ (double) (errorCorrectionFrequency + 1));
		}
		return number;
	}

	/**
	 * This value is read from the header information. It is only available
	 * after the first frame is being read.
	 * 
	 * @return The number of total frames.
	 */
	public int getTotalFrames() {
		return totalFrames;
	}

	/**
	 * This value is read from the header information. It is only available
	 * after the first frame is being read. The filename is not mandatory. It
	 * can be empty.
	 * 
	 * @return The filename provided by the MultipleCodeGenerator instance.
	 */
	public String getFilename() {
		return filename;
	}

	/**
	 * Returns the data read from the barcodes. This method is blocking.
	 * Therefore it can be only used in a multi-threaded environment. The method
	 * has to be called multiple times to get all data from the barcodes. The
	 * caller has to know when all the data is received. This method gives no
	 * indication to when to stop with reading.
	 * 
	 * @return The data read from the barcodes.
	 */
	public byte[] read() throws IOException {
		// Clean the buffer
		while (true) {
			if (disposed)
				throw new IOException("Object is disposed!");

			Set<Integer> keys = orderBuffer.keySet();
			for (int key : keys) {
				if ((!errorCorrection && key < frameNumber)
						|| (errorCorrection && key < frameNumber
								- errorCorrectionFrequency))
					orderBuffer.remove(orderBuffer.get(key));

			}

			if (getTotalFrames() <= calculateFrameNumber(frameNumber) + 1)
				throw new IOException("All the data is read!");

			int newFrame;
			// Ignore the error correction frames
			if (errorCorrection
					&& (frameNumber + 1) % (errorCorrectionFrequency + 1) == 1) {
				newFrame = frameNumber + 2;

				// Check in while loop isn't hard enough
				if (getTotalFrames() <= calculateFrameNumber(newFrame))
					throw new IllegalArgumentException("All the data is read!");

			} else {
				newFrame = frameNumber + 1;
			}

			Frame frame = orderBuffer.get(newFrame);

			if (frame != null) {
				if (newFrame == 0) {
					/*
					 * Try to read the header of the first frame.
					 */
					if (!(frame instanceof HeaderFrame))
						throw new IOException("This is not the first frame!");

					HeaderFrame firstFrame = (HeaderFrame) frame;

					totalFrames = firstFrame.getTotalFrames();
					size = firstFrame.getSize();
					filename = firstFrame.getFilename();
					errorCorrectionFrequency = firstFrame
							.getErrorCorrectionLevel();
					if (errorCorrectionFrequency > 0)
						errorCorrection = true;
				}

				synchronized (this) {
					frameNumber = newFrame;
					return returnFrameData(frame);
				}
			}

			// Try to do the error correction
			if (frame == null && errorCorrection) {
				int errorCorrectionFrame = newFrame
						- ((newFrame - 1) % (errorCorrectionFrequency + 1));

				List<Frame> errorCorrectionFrames = new ArrayList<Frame>();
				// Correction is only possible with the error correction
				// frame
				Frame ef = orderBuffer.get(errorCorrectionFrame);

				if (ef != null)
					errorCorrectionFrames.add(ef);

				// We have to have at least frequency-1 other frames to
				// restore our frame.
				for (int i = 1; i < errorCorrectionFrequency + 1; i++) {
					// Ignore the missing frame.
					if (errorCorrectionFrame + i == newFrame)
						continue;

					Frame f = orderBuffer.get(errorCorrectionFrame + i);
					if (f != null)
						errorCorrectionFrames.add(f);
				}

				// We have all the frames to do error correction
				if (errorCorrectionFrames.size() == errorCorrectionFrequency
						|| errorCorrectionFrames.size() == (totalFrames
								+ (int) Math.ceil((totalFrames - 1)
										/ errorCorrectionFrequency) - (newFrame - ((newFrame - 1) % (errorCorrectionFrequency + 1))))) {
					Frame c = Frame.restore(errorCorrectionFrames
							.toArray(new Frame[errorCorrectionFrames.size()]));

					synchronized (this) {
						frameNumber = newFrame;
						return returnFrameData(c);
					}
				}
			}

			synchronized (read) {
				try {
					read.wait();
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		}
	}

	/**
	 * Returns if there is still data to read on the buffer or if not all the
	 * data is transfered yet.
	 * 
	 * @return True if there is still data to read.
	 */
	public synchronized boolean hasMore() {
		return !(getTotalFrames() <= calculateFrameNumber(frameNumber) + 1);
	}

	/**
	 * Push a barcode in the ARGB bitmap image format to this instance. The
	 * method blocks till the whole barcode is being processed. The images have
	 * to be pushed in the right order.
	 * 
	 * @param data
	 *            The image with the barcode in ARGB bitmap format.
	 * @throws NotDetectedException
	 *             The barcode was not detected in the image.
	 * @throws DecodeException
	 *             An exception happened with the decoding of the detected
	 *             barcode.
	 * @throws FrameMissedException
	 *             A frame is missing or the frames are not in the right order.
	 */
	public void pushFrame(int[] data) throws NotDetectedException,
			DecodeException {
		if (disposed)
			throw new DecodeException("Object is disposed!");

		Frame decoded = Frame.createFrameFromData(reader.decode(data));

		if (decoded != null)
			synchronized (read) {
				orderBuffer.put(decoded.getFrameNumber(), decoded);
				read.notify();
			}
	}

	/**
	 * Disposes the object. The {@link MultipleCodeReader#read()} method throws
	 * an Exception instantly. The {@link MultipleCodeReader#pushFrame(int[])}
	 * also throws an Exception after calling this method.
	 */
	public void dispose() {
		disposed = true;
		// Null is dangerous because methods could still be accessing it
		orderBuffer = new ConcurrentHashMap<Integer, Frame>();
		synchronized (read) {
			read.notify();
		}
	}

	private byte[] returnFrameData(Frame decoded) {
		byte[] data = decoded.getData();

		// Trim NULL values at the end
		sizeRead += data.length;
		if (sizeRead > size) {
			sizeRead -= data.length;
			return Arrays.copyOf(data, size - sizeRead);
		} else {
			return data;
		}
	}
}
